Skip to content

fix(credits): quote minutes from the backend's rate, not a compiled-in copy - #126

Merged
alpha5611331 merged 4 commits into
mainfrom
fix/credit-system-correctness
Sep 9, 2026
Merged

fix(credits): quote minutes from the backend's rate, not a compiled-in copy#126
alpha5611331 merged 4 commits into
mainfrom
fix/credit-system-correctness

Conversation

@alpha5611331

Copy link
Copy Markdown
Member

Consumes the credits_per_minute half of PowerInterviewAI/backend#63. Paired with alpha5611331/pia-admin#7 (the admin dashboard has no repo in this org).

What changed

The per-minute rate is no longer a compiled-in constant. CREDITS_PER_MINUTE is an env-overridable backend deployment setting, but the client computed every "minutes remaining" figure from a hardcoded copy of the shipped default - so the number quoted to the user could silently disagree with what they were actually charged. SPEC.md already claimed the client held no local pricing: true of plans and mock pricing, false of this one. The backend's ping now carries credits_per_minute, AppState.creditsPerMinute follows it through to CreditsDisplay, BuyCreditsTab and the status panel, and the old constant is kept only as a fallback for before the first ping answers or against a backend old enough not to send it. (undefined there means "not answered yet" - never free, and never the shipped default.)

A real bug found while wiring that through: use-app-state's normalize() never copied mockPricing from main's state at all, so AppState.mockPricing was always undefined in the renderer regardless of what the backend sent. The mock-interview affordability gate this field exists to drive therefore never engaged - the setup form's pricing and the home card's appState?.mockPricing both read as "this backend predates per-turn pricing", which quotes nothing and gates nothing, even against a backend that does send it.

An unusable rate is rejected at the boundary. A zero or negative CREDITS_PER_MINUTE - possible precisely because it is an env override, which is the whole reason it is served rather than compiled in - would divide a balance into Infinity minutes or a negative duration and render it. Sanitised once in the ping handler rather than at each consumer, so anything that is not a usable rate arrives as undefined: a case the renderer already handles by falling back to its own mirror.

Two smaller things alongside:

  • CreditsDisplay labeled every non-trial user "Pro Plan" - a real purchasable SKU that a starter or enterprise buyer never bought. Relabeled to "Paid Plan".
  • The renderer -> main app:update-state IPC handler applied a renderer's updates object unfiltered, so credits, creditsPerMinute, userRole and mockPricing were all writable from the renderer. A value written that way would stand until the next ping overwrote it, up to a failure-backoff interval later. Now stripped at the boundary; these four are derived from the authenticated ping only. No renderer code writes them today, so this closes an unguarded path onto a financial field rather than changing any behaviour.

Review notes

The restored mockPricing wiring switches on a gate that was previously always inert, which is a real behaviour change for users. Its formula (per_question * count + per_report) matches backend's session_price() exactly, so it cannot refuse a session the backend would then have allowed - it mirrors the backend's own upfront refusal in generate_question rather than adding a stricter one.

Verification

pnpm exec tsc -b    # clean
pnpm test:main      # passed (includes new creditsPerMinute app-state coverage)

🤖 Generated with Claude Code

alpha5611331 and others added 2 commits September 8, 2026 18:11
CREDITS_PER_MINUTE is an env-overridable backend setting, not a
constant, and SPEC.md already claimed the client held no local
pricing - true of plans and mock pricing, false of this one. The
backend's ping now carries credits_per_minute; AppState.creditsPerMinute
follows it through, with the old constant kept only as a fallback for
before the first ping answers or against a backend old enough not to
send it.

Also fixes a real bug found while wiring this through: use-app-state's
normalize() never copied mockPricing from main's state at all, so
AppState.mockPricing was always undefined in the renderer regardless
of what the backend sent - the mock-interview affordability gate this
field exists to drive never actually engaged.

Smaller things alongside it:
- CreditsDisplay labeled every non-trial user 'Pro Plan', a real
  purchasable SKU a starter/enterprise buyer never bought - relabeled
  to 'Paid Plan'
- the renderer->main app:update-state IPC handler applied a renderer's
  updates unfiltered, so credits/creditsPerMinute/userRole/mockPricing
  were writable from the renderer; now stripped at the boundary

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The per-minute rate became a number the backend supplies rather than a
compiled-in constant, and it is the divisor behind every "minutes
remaining" figure the UI shows. A zero or negative `CREDITS_PER_MINUTE`
- possible precisely because it is an env-overridable deployment
setting, which is why it is served at all - would render a balance as
`Infinity` minutes or a negative duration.

Sanitised once at the ping boundary rather than at each consumer, so
anything that is not a usable rate arrives as `undefined`: a case the
renderer already handles by falling back to its own mirror.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@gitar-bot

gitar-bot Bot commented Sep 9, 2026

Copy link
Copy Markdown

Gitar is working

Gitar

alpha5611331 and others added 2 commits September 9, 2026 09:11
While the interviewer was speaking and the transcript was already longer
than its panel, the bottom of the session screen grew and settled back on
every question. Two things together:

The panel wrappers inside the panel-and-status column set no `overflow`
and no `min-h-0`, so their minimum height was their content's, and the
panel they hold is `h-full`, which reads as `auto` while that minimum is
computed. Past the point where the transcript outgrew the row, each
wrapper's floor was taller than the row and the panel overflowed the
column. The column clips, so none of it showed, but clipping is what gave
the column a scroll range.

`scrollIntoView` then reached it. It brings its target into view inside
every scrollable ancestor, not just the nearest, and Chromium scrolls an
`overflow: hidden` box programmatically, so the transcript's own
auto-scroll dragged the column along with it, status line and control bar
included. The panel now scrolls its own scroller by name and cannot move
anything above it.

The column also asked for `overflow-y-hidden`, which leaves the other
axis computing to `auto` rather than staying visible: a row that overran
the width would put a ten-pixel horizontal scrollbar directly above the
control bar. Both axes clip now, and the transcript scroller pins its
horizontal axis for the same reason.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Auto-scroll was keyed on the turn list, which is a list of finished
strings. The question arrives whole and StreamingQuestion writes it out
in the DOM, so nothing the effect watched changed for the entire reveal:
the panel sat still while the interviewer spoke and only caught up on
the next state change, which read as auto-scroll working only once the
voice ended.

Observe the list's box with a ResizeObserver instead, so the panel
follows the content whenever it actually gets taller - the reveal, an
ASR partial wrapping to a new line, a reflow on a dock resize. Those
arrive a line at a time, so auto-follow scrolls instantly; the
scroll-to-bottom button keeps the smooth scroll, where the reader asked
for one long jump.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@alpha5611331 alpha5611331 self-assigned this Sep 9, 2026
@alpha5611331
alpha5611331 merged commit 3332b8f into main Sep 9, 2026
1 check passed
@alpha5611331
alpha5611331 deleted the fix/credit-system-correctness branch September 9, 2026 13:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant